package editor.animation;

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

// represents tracks on a timeline; each track contains keyframes
public class Timeline implements MouseMotionListener, MouseListener, ComponentListener {
	private static final int PANEL_HEIGHT = 24;
	private static final int BUTTON_WIDTH = 24;
	private static final int BUTTON_HEIGHT = 24;

	public Timeline() {
		container = new JPanel();
		GroupLayout topLayout = new GroupLayout(container);
		container.setLayout(topLayout);

		JPanel labelPanel = new JPanel();
		{
			labelPanel.setLayout(new GridLayout(1, 3));
			labelPanel.setBorder(BorderFactory.createEmptyBorder(0, 2 * BUTTON_WIDTH, 0, 2 * BUTTON_WIDTH));

			startTimeLabel = new JLabel("start", JLabel.LEADING);
			labelPanel.add(startTimeLabel);

			mouseTimeLabel = new JLabel("time", JLabel.CENTER);
			mouseTimeLabel.setVisible(false);
			labelPanel.add(mouseTimeLabel);

			endTimeLabel = new JLabel("end", JLabel.TRAILING);
			labelPanel.add(endTimeLabel);
		}

		JPanel timelinePanel = new JPanel();
		{
			BoxLayout layout = new BoxLayout(timelinePanel, BoxLayout.LINE_AXIS);
			timelinePanel.setLayout(layout);

			timeline = new JPanel() {
				@Override
				public void paintChildren(Graphics g) {
					Timeline.this.paint(g);
				}
			};
			timeline.setBorder(BorderFactory.createLineBorder(Color.BLACK));
			timeline.setSize(new Dimension(timeline.getMinimumSize().width, PANEL_HEIGHT));
			timeline.setBackground(Color.WHITE);
			timeline.addMouseMotionListener(this);
			timeline.addMouseListener(this);
			timeline.addComponentListener(this);

			firstKfButton = new JButton("<<");
			firstKfButton.setMargin(new Insets(0, 0, 0, 0));
			firstKfButton.setPreferredSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			firstKfButton.setMinimumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			firstKfButton.setMaximumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			firstKfButton.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					selectFirstKeyframe(true);
				}
			});
			prevKfButton = new JButton("<");
			prevKfButton.setMargin(new Insets(0, 0, 0, 0));
			prevKfButton.setPreferredSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			prevKfButton.setMinimumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			prevKfButton.setMaximumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			prevKfButton.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					selectPrevKeyframe(true);
				}
			});

			nextKfButton = new JButton(">");
			nextKfButton.setMargin(new Insets(0, 0, 0, 0));
			nextKfButton.setPreferredSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			nextKfButton.setMinimumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			nextKfButton.setMaximumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			nextKfButton.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					selectNextKeyframe(true);
				}
			});
			lastKfButton = new JButton(">>");
			lastKfButton.setMargin(new Insets(0, 0, 0, 0));
			lastKfButton.setPreferredSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			lastKfButton.setMinimumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			lastKfButton.setMaximumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			lastKfButton.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					selectLastKeyframe(true);
				}
			});

			timelinePanel.add(firstKfButton);
			timelinePanel.add(prevKfButton);
			timelinePanel.add(timeline);
			timelinePanel.add(nextKfButton);
			timelinePanel.add(lastKfButton);
		}

		JPanel controlsPanel = new JPanel();
		{
			GroupLayout layout = new GroupLayout(controlsPanel);
			controlsPanel.setLayout(layout);

			zoomOutButton = new JButton("-");
			zoomOutButton.setMargin(new Insets(0, 0, 0, 0));
			zoomOutButton.setPreferredSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			zoomOutButton.setMinimumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			zoomOutButton.setMaximumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			zoomOutButton.setAlignmentX(Component.LEFT_ALIGNMENT);
			zoomOutButton.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					float oldCenterTime = coordsToTime(timeline.getWidth()/2);
					zoom = Math.max(zoom*0.5f, MIN_ONE_SECOND_PIXELS);
					int newCenterOffset = timeToCoords(oldCenterTime);
					offset += newCenterOffset - timeline.getWidth()/2;
					recomputeTimeLabels();
					timeline.repaint();
				}
			});

			zoomInButton = new JButton("+");
			zoomInButton.setMargin(new Insets(0, 0, 0, 0));
			zoomInButton.setPreferredSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			zoomInButton.setMinimumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			zoomInButton.setMaximumSize(new Dimension(BUTTON_WIDTH, BUTTON_HEIGHT));
			zoomInButton.setAlignmentX(Component.LEFT_ALIGNMENT);
			zoomInButton.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					float oldCenterTime = coordsToTime(timeline.getWidth()/2);
					zoom = Math.min(zoom*2.0f, MAX_ONE_SECOND_PIXELS);
					int newCenterOffset = timeToCoords(oldCenterTime);
					offset += newCenterOffset - timeline.getWidth()/2;
					recomputeTimeLabels();
					timeline.repaint();
				}
			});

			fpsQuantizationCheckbox = new JCheckBox("FPS quantization");
			fpsSpinner = new JSpinner(new SpinnerNumberModel(20, 0, 1000, 1));
			fpsSpinner.setSize(new Dimension(64, fpsSpinner.getPreferredSize().height));

			JPanel space = new JPanel();
			layout.setHorizontalGroup(layout.createSequentialGroup()
					.addComponent(zoomOutButton)
					.addComponent(zoomInButton)
					.addComponent(space)
					.addComponent(fpsQuantizationCheckbox)
					.addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
					.addComponent(fpsSpinner));
			layout.setVerticalGroup(layout.createParallelGroup(GroupLayout.Alignment.BASELINE)
					.addComponent(zoomOutButton)
					.addComponent(zoomInButton)
					.addComponent(space)
					.addComponent(fpsQuantizationCheckbox)
					.addComponent(fpsSpinner));
		}

		topLayout.setHorizontalGroup(topLayout.createParallelGroup()
				.addComponent(labelPanel)
				.addComponent(timelinePanel)
				.addComponent(controlsPanel));
		topLayout.setVerticalGroup(topLayout.createSequentialGroup()
				.addComponent(labelPanel)
				.addComponent(timelinePanel)
				.addComponent(controlsPanel));

		keyframes = new HashMap<Integer, ArrayList<Float>>();
		activeTrack = -1;

		offset = -1; // offset by 1 initially due to 1-pixel border
		zoom = ONE_SECOND_PIXELS;

		currentTime = 0.0f;
	}

	public void setEnabled(boolean enabled) {
		firstKfButton.setEnabled(enabled);
		prevKfButton.setEnabled(enabled);
		nextKfButton.setEnabled(enabled);
		lastKfButton.setEnabled(enabled);
		zoomOutButton.setEnabled(enabled);
		zoomInButton.setEnabled(enabled);
		fpsQuantizationCheckbox.setEnabled(enabled);
		fpsSpinner.setEnabled(enabled);
		if (enabled && !timeline.isEnabled()) {
			timeline.addMouseMotionListener(this);
			timeline.addMouseListener(this);
			timeline.addComponentListener(this);
		} else if (!enabled && timeline.isEnabled()) {
			timeline.removeMouseMotionListener(this);
			timeline.removeMouseListener(this);
			timeline.removeComponentListener(this);
		}
		timeline.setEnabled(enabled);
	}

	public void setColors(Color activeRange, Color activeTrackKeyframe, Color inactiveTrackKeyframe, Color selectedKeyframe) {
		activeRangeColor = activeRange;
		activeTrackKeyframeColor = activeTrackKeyframe;
		activeTrackKeyframeLineColor = new Color(activeTrackKeyframe.getRed()/2, activeTrackKeyframe.getGreen()/2, activeTrackKeyframe.getBlue()/2);
		inactiveTrackKeyframeColor = inactiveTrackKeyframe;
		inactiveTrackKeyframeLineColor = new Color(inactiveTrackKeyframe.getRed()/2, inactiveTrackKeyframe.getGreen()/2, inactiveTrackKeyframe.getBlue()/2);
		selectedKeyframeColor = selectedKeyframe;
		selectedKeyframeLineColor = new Color(selectedKeyframe.getRed()/2, selectedKeyframe.getGreen()/2, selectedKeyframe.getBlue()/2);
		timeline.repaint();
	}

	// sets the range of the timeline (i.e. the part drawn in a different color
	public void setActiveRange(float start, float end) {
		activeRangeStart = start;
		activeRangeEnd = end;
		timeline.repaint();
	}

	// returns whether a keyframe exists on the given track at the given time
	public boolean hasKeyframe(int track, float time) {
		ArrayList<Float> kfList = keyframes.get(track);
		return kfList != null && Collections.binarySearch(kfList, time) >= 0;
	}

	// adds a keyframe to the given track at the given time
	public void addKeyframe(int track, float time, boolean call) {
		if (track < 0) {
			throw new IllegalArgumentException("Invalid track");
		}
		ArrayList<Float> kfList = keyframes.get(track);
		if (kfList == null) {
			kfList = new ArrayList<Float>();
			keyframes.put(track, kfList);
		}

		int index = Collections.binarySearch(kfList, time);
		if (index >= 0) {
			throw new IllegalArgumentException("Keyframe already exists");
		}

		kfList.add(-index - 1, time);
		if (call && listener != null) {
			listener.keyframeAdded(track, time);
		}
		timeline.repaint();
	}

	// removes the active keyframe at the given time
	public void removeKeyframe(int track, float time, boolean call) {
		if (track < 0) {
			throw new IllegalArgumentException("Invalid track");
		}
		ArrayList<Float> kfList = keyframes.get(track);
		if (kfList == null) {
			throw new IllegalArgumentException("Keyframe does not exist");
		}

		int index = Collections.binarySearch(kfList, time);
		if (index < 0) {
			throw new IllegalArgumentException("Keyframe does not exist");
		}

		kfList.remove(index);
		if (kfList.isEmpty()) {
			keyframes.remove(track);
		}
		if (call && listener != null) {
			listener.keyframeRemoved(track, time);
		}
		timeline.repaint();
	}

	public void selectKeyframeAtCurrentTime(boolean call) {
		if (selectedTime != null && selectedTime.equals(currentTime)) {
			return;
		}

		ArrayList<Float> kfList = keyframes.get(activeTrack);
		if (kfList == null) {
			throw new IllegalArgumentException("Keyframe does not exist");
		}

		int index = Collections.binarySearch(kfList, currentTime);
		if (index < 0) {
			throw new IllegalArgumentException("Keyframe does not exist");
		}

		// select
		Float prevSelection = selectedTime;
		selectedTime = currentTime;
		if (call && listener != null) {
			listener.keyframeSelected(activeTrack, prevSelection, selectedTime);
		}

		timeline.repaint();
	}

	public void selectFirstKeyframe(boolean call) {
		ArrayList<Float> kfList = keyframes.get(activeTrack);
		if (kfList == null || kfList.isEmpty()) {
			return;
		}
		Float prevTime = selectedTime;
		selectedTime = kfList.get(0);
		if (call && !selectedTime.equals(prevTime) && listener != null) {
			listener.keyframeSelected(activeTrack, prevTime, selectedTime);
		}
		centerOffsetOnKeyframe();
		setCurrentTime(selectedTime, true);
	}

	public void selectPrevKeyframe(boolean call) {
		ArrayList<Float> kfList = keyframes.get(activeTrack);
		if (kfList == null || kfList.isEmpty() || selectedTime == null) {
			return;
		}
		Float prevTime = selectedTime;
		int index = Collections.binarySearch(kfList, selectedTime);
		assert(index >= 0);
		selectedTime = kfList.get(Math.max(index-1, 0));
		if (call && !selectedTime.equals(prevTime) && listener != null) {
			listener.keyframeSelected(activeTrack, prevTime, selectedTime);
		}
		centerOffsetOnKeyframe();
		setCurrentTime(selectedTime, true);
	}

	public void selectNextKeyframe(boolean call) {
		ArrayList<Float> kfList = keyframes.get(activeTrack);
		if (kfList == null || kfList.isEmpty() || selectedTime == null) {
			return;
		}
		Float prevTime = selectedTime;
		int index = Collections.binarySearch(kfList, selectedTime);
		assert(index >= 0);
		selectedTime = kfList.get(Math.min(index+1, kfList.size()-1));
		if (call && !selectedTime.equals(prevTime) && listener != null) {
			listener.keyframeSelected(activeTrack, prevTime, selectedTime);
		}
		centerOffsetOnKeyframe();
		setCurrentTime(selectedTime, true);
	}

	public void selectLastKeyframe(boolean call) {
		ArrayList<Float> kfList = keyframes.get(activeTrack);
		if (kfList == null || kfList.isEmpty()) {
			return;
		}
		Float prevTime = selectedTime;
		selectedTime = kfList.get(kfList.size()-1);
		if (call && !selectedTime.equals(prevTime) && listener != null) {
			listener.keyframeSelected(activeTrack, prevTime, selectedTime);
		}
		centerOffsetOnKeyframe();
		setCurrentTime(selectedTime, true);
	}

	// sets which track is active
	public void setActiveTrack(int track, boolean call) {
		if (activeTrack != track) {
			Float prevSelection = selectedTime;
			selectedTime = null;
			if (call && listener != null) {
				listener.keyframeSelected(activeTrack, prevSelection, selectedTime);
			}
		}

		activeTrack = track;
		timeline.repaint();
	}

	// clears all keyframes from all tracks
	public void clearKeyframes() {
		keyframes.clear();
		timeline.repaint();
	}

	public void setCurrentTime(float currentTime, boolean call) {
		this.currentTime = currentTime;
		if (call && listener != null) {
			listener.currentTimeChanged(currentTime);
		}
		timeline.repaint();
	}

	public float getCurrentTime() {
		return currentTime;
	}

	public interface TimelineListener {
		// called when keyframe is selected (prevTime is old selection, newTime is new selection)
		public void keyframeSelected(int track, Float prevTime, Float newTime);

		// called when keyframe is added
		public void keyframeAdded(int track, Float time);

		// called when keyframe is removed
		public void keyframeRemoved(int track, Float time);

		// called when keyframe is moved
		public void keyframeMoved(int track, Float prevTime, Float newTime);

		// called when current time is changed
		public void currentTimeChanged(float currentTime);
	}

	public void setTimelineListener(TimelineListener listener) {
		this.listener = listener;
	}

	public JComponent getComponent() {
		return container;
	}

	private JPanel container;
	private JPanel timeline;
	private JButton firstKfButton;
	private JButton prevKfButton;
	private JButton nextKfButton;
	private JButton lastKfButton;
	private JLabel startTimeLabel;
	private JLabel endTimeLabel;
	private JLabel mouseTimeLabel;
	private JButton zoomOutButton;
	private JButton zoomInButton;
	private JCheckBox fpsQuantizationCheckbox;
	private JSpinner fpsSpinner;

	private Color activeRangeColor;
	private Color activeTrackKeyframeColor;
	private Color activeTrackKeyframeLineColor;
	private Color inactiveTrackKeyframeColor;
	private Color inactiveTrackKeyframeLineColor;
	private Color selectedKeyframeColor;
	private Color selectedKeyframeLineColor;
	private float activeRangeStart;
	private float activeRangeEnd;
	private HashMap<Integer, ArrayList<Float>> keyframes;
	private int activeTrack;
	private TimelineListener listener;
	private Float selectedTime;
	private float currentTime;

	private int offset;
	private float zoom;

	private static final float ONE_SECOND_PIXELS = 128.0f;
	private static final float MIN_ONE_SECOND_PIXELS = 1.0f;
	private static final float MAX_ONE_SECOND_PIXELS = 1024.0f;
	private static final int KEYFRAME_WIDTH = 8;

	private int timeToCoords(float time) {
		return (int) (time * zoom) - offset;
	}

	private float coordsToTime(int coords) {
		return (float) (coords + offset) / zoom;
	}

	private int timeLabelX;
	private int dragX;
	private boolean dragging;
	private boolean clickedLeft;
	private boolean clickedKeyframe;
	private boolean moving;
	private float movingTime;

	// finds keyframe closest to mouseOffset in the active track
	private Float findKeyframeUnderMouse(int mouseOffset) {
		// select if we're over a keyframe
		ArrayList<Float> kfList = keyframes.get(activeTrack);
		if (kfList == null) {
			return null;
		}

		Float closest = null;
		int closestDist = 0;
		for (Float aKfList : kfList) {
			int x = timeToCoords(aKfList);
			int x0 = x - KEYFRAME_WIDTH / 2;
			int x1 = x0 + KEYFRAME_WIDTH;
			int dist = Math.abs(x - mouseOffset);
			if (mouseOffset >= x0 && mouseOffset < x1) {
				if (closest == null || dist < closestDist) {
					closest = aKfList;
					closestDist = dist;
				}
			}
		}
		return closest;
	}

	@Override
	public void mouseMoved(MouseEvent e) {
		timeLabelX = e.getX();
		recomputeTimeLabels();
	}

	@Override
	public void mouseDragged(MouseEvent e) {
		if (dragging) {
			offset += (dragX - e.getX());
			dragX = e.getX();
		} else if (clickedKeyframe) {
			// see if we are moving the selection
			if (!moving) {
				// see if we have moved the mouse far enough to start dragging the keyframe
				int coords = timeToCoords(selectedTime);
				int x0 = coords - KEYFRAME_WIDTH/2;
				int x1 = x0 + KEYFRAME_WIDTH;
				if (e.getX() < x0 || e.getX() >= x1) {
					moving = true;
				}
			}

			if (moving) {
				movingTime = getQuantizedTime(coordsToTime(e.getX()));
				setCurrentTime(movingTime, true);
			}
		} else if (clickedLeft) {
			float time = getQuantizedTime(coordsToTime(e.getX()));
			setCurrentTime(time, true);
		}
		timeLabelX = e.getX();
		recomputeTimeLabels();
		timeline.repaint();
	}

	@Override
	public void mouseClicked(MouseEvent e) {
		if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 2 && activeTrack >= 0) {
			float time = getQuantizedTime(coordsToTime(e.getX()));

			ArrayList<Float> kfList = keyframes.get(activeTrack);
			if (kfList == null) {
				kfList = new ArrayList<Float>();
				keyframes.put(activeTrack, kfList);
			}

			int index = Collections.binarySearch(kfList, time);
			if (index >= 0) {
				return; // keyframe already exists there
			}

			kfList.add(-index - 1, time);
			if (listener != null) {
				listener.keyframeAdded(activeTrack, time);
			}
			// select
			Float prevSelection = selectedTime;
			selectedTime = time;
			if (listener != null) {
				listener.keyframeSelected(activeTrack, prevSelection, selectedTime);
			}
			setCurrentTime(selectedTime, true);

			timeline.repaint();
		}
	}

	@Override
	public void mouseEntered(MouseEvent e) {
		mouseTimeLabel.setVisible(true);
	}

	@Override
	public void mouseExited(MouseEvent e) {
		mouseTimeLabel.setVisible(false);
	}

	@Override
	public void mousePressed(MouseEvent e) {
		clickedLeft = e.getButton() == MouseEvent.BUTTON1;
		dragging = clickedLeft && e.isControlDown();
		clickedKeyframe = false;
		moving = false;
		if (dragging) {
			dragX = e.getX();
			return;
		}

		Float closest = findKeyframeUnderMouse(e.getX());
		if (closest != null && clickedLeft) {
			clickedKeyframe = true;
		}

		if (e.getButton() == MouseEvent.BUTTON1) {
			// select if we're over a keyframe
			boolean different = (closest == null && selectedTime != null) || (closest != null && !closest.equals(selectedTime));
			if (different) {
				Float prevTime = selectedTime;
				selectedTime = closest;
				if (listener != null) {
					listener.keyframeSelected(activeTrack, prevTime, selectedTime);
				}
				timeline.repaint();
			}
			if (closest != null) {
				setCurrentTime(closest, true);
			} else {
				float time = getQuantizedTime(coordsToTime(e.getX()));
				setCurrentTime(time, true);
			}
		} else if (e.getButton() == MouseEvent.BUTTON3) {
			if (closest != null) {
				boolean different = !closest.equals(selectedTime);
				if (!different) {
					// deselect first
					Float prevSelection = selectedTime;
					selectedTime = null;
					if (listener != null) {
						listener.keyframeSelected(activeTrack, prevSelection, selectedTime);
					}
				}
				// remove the keyframe
				ArrayList<Float> kfList = keyframes.get(activeTrack);
				kfList.remove(closest);
				if (kfList.isEmpty()) {
					keyframes.remove(activeTrack);
				}
				if (listener != null) {
					listener.keyframeRemoved(activeTrack, closest);
				}
				timeline.repaint();
			}
		}
	}

	@Override
	public void mouseReleased(MouseEvent e) {
		if (moving) {
			moving = false;
			// try placing the selected/dragged keyframe at the new location
			movingTime = getQuantizedTime(coordsToTime(e.getX()));

			ArrayList<Float> kfList = keyframes.get(activeTrack);
			int index = Collections.binarySearch(kfList, movingTime);
			if (index < 0) {
				// no keyframe here - we can move it
				kfList.add(-index - 1, movingTime);
				kfList.remove(selectedTime);
				if (listener != null) {
					listener.keyframeMoved(activeTrack, selectedTime, movingTime);
				}
				// select the new time
				Float prevSelection = selectedTime;
				selectedTime = movingTime;
				if (listener != null) {
					listener.keyframeSelected(activeTrack, prevSelection, selectedTime);
				}
			}
			timeline.repaint();
		}
	}

	@Override
	public void componentHidden(ComponentEvent e) {}

	@Override
	public void componentMoved(ComponentEvent e) {}

	@Override
	public void componentResized(ComponentEvent e) {
		recomputeTimeLabels();
	}

	@Override
	public void componentShown(ComponentEvent e) {}

	private void centerOffsetOnKeyframe() {
		int kfCoords = timeToCoords(selectedTime);
		// want to make sure that the keyframe is within the view (padded by X%)
		final float PADDING_RATIO = 0.2f;
		int padding = (int) (PADDING_RATIO * (float) timeline.getWidth());
		int endPadding = timeline.getWidth() - padding;
		if (kfCoords < padding) {
			offset -= (padding - kfCoords);
		} else if (kfCoords > endPadding) {
			offset += (kfCoords - endPadding);
		}

		timeline.repaint();
	}

	private void recomputeTimeLabels() {
		float startTime = coordsToTime(1); // 1 pixel offset
		float endTime = coordsToTime(timeline.getWidth()-1); // 1 pixel offset
		float mouseTime = getQuantizedTime(coordsToTime(timeLabelX));
		DecimalFormat fmt = new DecimalFormat("#.####");
		startTimeLabel.setText(fmt.format(startTime));
		endTimeLabel.setText(fmt.format(endTime));
		mouseTimeLabel.setText(fmt.format(mouseTime));
	}

	// returns quantized time if quantization is enabled; returns time otherwise
	private float getQuantizedTime(float time) {
		if (fpsQuantizationCheckbox.isSelected()) {
			int quant = ((Number) fpsSpinner.getValue()).intValue();
			int frame = Math.round(time*quant);
			return (float) frame / (float) quant;
		} else {
			return time;
		}
	}

	private void paint(Graphics g) {
		// graphics origin starts at 0, but clip over by 1 pixel for border
		Graphics cg = g.create(0, 1, timeline.getWidth(), timeline.getHeight()-2);
		cg.clipRect(1, 0, timeline.getWidth()-2, timeline.getHeight()-2);
		int h = timeline.getHeight()-2;
		try {
			// first paint the active region
			int activeRangeX0 = timeToCoords(activeRangeStart);
			int activeRangeX1 = timeToCoords(activeRangeEnd);
			if (cg.hitClip(activeRangeX0, 0, activeRangeX1 - activeRangeX0, h)) {
				// draw active range box
				cg.setColor(activeRangeColor);
				cg.fillRect(activeRangeX0, 0, activeRangeX1 - activeRangeX0, h);
			}

			// draw inactive tracks
			for (Map.Entry<Integer, ArrayList<Float>> e : keyframes.entrySet()) {
				if (e.getKey() == activeTrack) {
					continue;
				}
				for (int i = 0; i < e.getValue().size(); ++i) {
					int kfX = timeToCoords(e.getValue().get(i));
					int kfX0 = kfX - KEYFRAME_WIDTH/2;
					int kfX1 = kfX0 + KEYFRAME_WIDTH;
					if (cg.hitClip(kfX0, 0, kfX1 - kfX0, h)) {
						cg.setColor(inactiveTrackKeyframeColor);
						cg.fillRect(kfX0, 0, kfX1 - kfX0, h);
						cg.setColor(inactiveTrackKeyframeLineColor);
						cg.drawRect(kfX0, 0, kfX1 - kfX0 - 1, h - 1);
					}
				}
			}
			// draw active track
			ArrayList<Float> activeKeyframes = keyframes.get(activeTrack);
			if (activeKeyframes != null) {
				for (Float activeKeyframe : activeKeyframes) {
					int kfX;
					if (moving && activeKeyframe.equals(selectedTime)) {
						kfX = timeToCoords(movingTime);
					} else {
						kfX = timeToCoords(activeKeyframe);
					}
					int kfX0 = kfX - KEYFRAME_WIDTH / 2;
					int kfX1 = kfX0 + KEYFRAME_WIDTH;
					if (cg.hitClip(kfX0, 0, kfX1 - kfX0, h)) {
						boolean selected = (activeKeyframe.equals(selectedTime));
						cg.setColor(selected ? selectedKeyframeColor : activeTrackKeyframeColor);
						cg.fillRect(kfX0, 0, kfX1 - kfX0, h);
						cg.setColor(selected ? selectedKeyframeLineColor : activeTrackKeyframeLineColor);
						cg.drawRect(kfX0, 0, kfX1 - kfX0 - 1, h - 1);
					}
				}
			}
			int currentTimeCoords = timeToCoords(currentTime);
			g.setColor(Color.BLACK);
			g.drawLine(currentTimeCoords, 0, currentTimeCoords, h);
		} finally {
			cg.dispose();
		}
	}
}
